iT邦幫忙

2025 iThome 鐵人賽

DAY 7
0
自我挑戰組

給愛追劇的你:30天互動網站挑戰系列 第 11

Day 11:心得表單(新增/驗證)

  • 分享至 

  • xImage
  •  

昨天我們完成了收藏功能與收藏頁,今天把「心得」做出來。雖然只是前端表單,但好的驗證與回饋能讓體驗差很多,也為之後的心得牆打底。


1. 新增 review.html(頁面骨架+表單)

在專案根目錄建立 review.html,貼上以下內容:

<!DOCTYPE html>
<html lang="zh-TW">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
  <title>DramaWeb|心得撰寫</title>
  <link rel="stylesheet" href="style.css"/>
  <script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
</head>
<body>
  <!-- 跳到主內容(a11y) -->
  <a class="skip-link" href="#main">跳到主內容</a>

  <header>
    <button class="menu" aria-label="開啟選單"><span>&#9776;</span></button>
    <h1>DramaWeb</h1>
    <nav>
      <ul>
        <li><a href="front.html">首頁</a></li>
        <li><a href="type.html">類型篩選:</a></li>
        <li><a href="favorite.html">收藏清單:</a></li>
        <li><a href="review.html" aria-current="page">心得牆:</a></li>
      </ul>
    </nav>
  </header>

  <main id="main">
    <section class="news">
      <h2>撰寫你的觀後心得</h2>
      <p>選擇劇名、填入評分與心得,按下送出後會即時顯示在下方列表(Day 12 會加入永久保存)。</p>
    </section>

    <!-- 心得表單 -->
    <section class="review-form">
      <form id="reviewForm" novalidate>
        <!-- 劇名(支援 datalist) -->
        <div class="field">
          <label for="titleInput">劇名 <span class="req">*</span></label>
          <input id="titleInput" name="title" list="showList" placeholder="請輸入或選擇劇名" required>
          <datalist id="showList"><!-- 由 JS 依 SHOWS 填入 --></datalist>
          <p class="error" id="errTitle" role="alert" hidden>請輸入 2–40 字的劇名。</p>
        </div>

        <!-- 評分(1–10) -->
        <div class="field">
          <label for="ratingInput">評分(1–10)<span class="req">*</span></label>
          <input id="ratingInput" name="rating" type="number" min="1" max="10" step="0.1" placeholder="例如 8.5" required>
          <p class="error" id="errRating" role="alert" hidden>請輸入 1–10 的數字。</p>
        </div>

        <!-- 暱稱(選填) -->
        <div class="field">
          <label for="nameInput">暱稱(選填)</label>
          <input id="nameInput" name="name" maxlength="20" placeholder="未填則顯示為:匿名">
        </div>

        <!-- 心得內容 -->
        <div class="field">
          <label for="contentInput">心得內容 <span class="req">*</span></label>
          <textarea id="contentInput" name="content" rows="6" placeholder="至少 30 個字,分享你的觀影感受…" required></textarea>
          <div class="hint">
            <span>字數:<span id="contentCount">0</span></span>
          </div>
          <p class="error" id="errContent" role="alert" hidden>心得至少 30 字,最多 1000 字。</p>
        </div>

        <div class="actions">
          <button id="btnSubmitReview" class="btn" type="submit">送出心得(Ctrl/⌘+Enter)</button>
          <button id="btnResetReview" class="btn btn-clear" type="button">清空表單</button>
        </div>

        <p class="form-msg" id="formMsg" aria-live="polite" hidden>已送出!</p>
      </form>
    </section>

    <!-- 心得列表(Day 11 先存在記憶體,Day 12 才存 LocalStorage) -->
    <section class="review-list">
      <h2>最新心得</h2>
      <div id="reviewList" class="reviews">
        <div class="empty">目前還沒有心得,快成為第一個分享的人吧!</div>
      </div>
    </section>
  </main>

  <footer>
    <p>DramaWeb © 2025 All Rights Reserved.</p>
  </footer>

  <!-- 提供 SHOWS:載入 Day10 的 app.js -->
  <script src="js/app.js"></script>
  <!-- 今日腳本:表單驗證與渲染(記憶體) -->
  <script src="js/reviews.js"></script>
</body>
</html>

2. 在 style.css 末尾加入表單樣式

(沿用 Day 5 的設計變數)

/* Day 11:心得表單樣式 */
.review-form, .review-list{
  margin: 18px 0; padding: 18px 16px;
  background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
}
.field{ display: grid; gap: 6px; margin-bottom: 12px; }
.field label{ font-weight: 600; }
.field .req{ color: #ef4444; margin-left: 2px; }
.field input, .field textarea{
  border: 1px solid var(--border); border-radius: 10px; padding: 10px 12px; font-size: 1rem; background: #fff; color: var(--text);
}
.field input:focus, .field textarea:focus{ outline: 3px solid rgba(124,58,237,.2); outline-offset: 1px; }
.field input[aria-invalid="true"], .field textarea[aria-invalid="true"]{ border-color: #ef4444; background: #fff7f7; }
.error{ color: #b91c1c; font-size: .92rem; margin: 2px 0 0; }
.hint{ color: var(--muted); font-size: .9rem; }

.actions{ display: flex; gap: 10px; margin-top: 6px; }
.form-msg{ margin-top: 8px; color: #065f46; background: #ecfdf5; border: 1px solid #a7f3d0; padding: 8px 10px; border-radius: 8px; }

.reviews{ display: grid; gap: 12px; }
.review-item{
  border: 1px solid var(--border); border-radius: var(--radius); padding: 12px 14px; background: var(--surface);
}
.review-head{ display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.review-title{ margin: 0; font-weight: 700; }
.review-meta{ color: var(--muted); font-size: .92rem; }
.review-content{ margin: 6px 0 0; white-space: pre-wrap; word-break: break-word; }
.review-actions{ margin-top: 8px; display:flex; gap:8px; }
.btn-del{ border: 1px solid #ef4444; color: #ef4444; background: #fff; border-radius: 8px; padding: 6px 10px; cursor: pointer; }
.btn-del:hover{ background: #fef2f2; }

/* skip link(若尚未加入 Day7 的可複用這段) */
.skip-link {
  position: absolute; left: -9999px; top: 0;
  background: #111; color: #fff; padding: 8px 12px; border-radius: 8px;
}
.skip-link:focus { left: 12px; top: 12px; z-index: 1000; }

3. 新增 js/reviews.js(驗證+渲染到列表;只存在記憶體)

js/ 底下建立 reviews.js,貼上:

// js/reviews.js — Day 11:心得表單(驗證+記憶體渲染)
(function(){
  // 來源於 app.js(Day10 已曝露)
  const SHOWS  = window.DRAMA_SHOWS || [];
  const LABELS = window.DRAMA_LABELS || {};

  const $form    = $('#reviewForm');
  const $title   = $('#titleInput');
  const $rating  = $('#ratingInput');
  const $name    = $('#nameInput');
  const $content = $('#contentInput');
  const $count   = $('#contentCount');
  const $msg     = $('#formMsg');
  const $list    = $('#reviewList');
  const $reset   = $('#btnResetReview');

  // 建立 datalist(劇名下拉提示)
  function buildDatalist(){
    const $dl = $('#showList').empty();
    SHOWS.forEach(s => $dl.append(`<option value="${s.title}"></option>`));
  }

  // 內存中的心得列表
  const REVIEWS = []; // Day 12 會改用 LocalStorage

  // 簡易跳脫(避免 XSS)
  function esc(str){
    return String(str)
      .replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
      .replace(/"/g,'&quot;').replace(/'/g,'&#39;');
  }

  function render(){
    if (!REVIEWS.length){
      $list.html(`<div class="empty">目前還沒有心得,快成為第一個分享的人吧!</div>`);
      return;
    }
    const html = REVIEWS.slice().reverse().map(item => {
      const tags = (item.genres || []).map(g => LABELS[g] || g).join(' / ');
      const metaRight = tags ? ` · ${tags}` : '';
      return `
        <article class="review-item" data-id="${item.id}">
          <div class="review-head">
            <h3 class="review-title">${esc(item.title)}</h3>
            <span class="review-meta">⭐ ${item.rating} · ${esc(item.name || '匿名')}${metaRight}</span>
          </div>
          <p class="review-content">${esc(item.content)}</p>
          <div class="review-actions">
            <button class="btn-del" type="button">刪除</button>
          </div>
        </article>
      `;
    }).join('');
    $list.html(html);
  }

  // 即時字數
  $content.on('input', function(){
    $count.text($(this).val().length);
  });

  // 欄位驗證
  function setError($el, $errEl, cond, msg){
    if (cond){ // 有錯
      $el.attr('aria-invalid', 'true');
      if (msg) $errEl.text(msg);
      $errEl.prop('hidden', false);
    } else {
      $el.attr('aria-invalid', 'false');
      $errEl.prop('hidden', true);
    }
    return !cond;
  }

  function validate(){
    const titleVal = $title.val().trim();
    const ratingVal = parseFloat($rating.val());
    const contentVal = $content.val().trim();

    const okTitle   = setError($title,   $('#errTitle'),   !(titleVal.length >= 2 && titleVal.length <= 40));
    const okRating  = setError($rating,  $('#errRating'),  !(Number.isFinite(ratingVal) && ratingVal >= 1 && ratingVal <= 10));
    const okContent = setError($content, $('#errContent'), !(contentVal.length >= 30 && contentVal.length <= 1000));

    // 聚焦第一個錯誤
    if (!okTitle)   { $title.focus();   return false; }
    if (!okRating)  { $rating.focus();  return false; }
    if (!okContent) { $content.focus(); return false; }
    return true;
  }

  // 找出對應的劇(若有)
  function findShowByTitle(title){
    return SHOWS.find(s => s.title === title);
  }

  // 送出
  $form.on('submit', function(e){
    e.preventDefault();
    if (!validate()) return;

    const titleVal   = $title.val().trim();
    const ratingVal  = parseFloat($rating.val());
    const nameVal    = $name.val().trim() || '匿名';
    const contentVal = $content.val().trim();

    const match = findShowByTitle(titleVal);
    const review = {
      id: 'rv_' + Date.now(),
      title: titleVal,
      rating: ratingVal.toFixed(1),
      name: nameVal,
      content: contentVal,
      genres: match ? match.genres : []
    };
    REVIEWS.push(review);

    // 重設並提示
    $form[0].reset();
    $count.text('0');
    $msg.text('已送出!').prop('hidden', false);
    setTimeout(() => $msg.prop('hidden', true), 1500);

    render();
  });

  // Ctrl/⌘ + Enter 快速送出
  $(document).on('keydown', function(e){
    if ((e.ctrlKey || e.metaKey) && e.key === 'Enter'){
      $('#btnSubmitReview').click();
    }
  });

  // 清空表單
  $reset.on('click', function(){
    $form[0].reset();
    $count.text('0');
    // 清除錯誤狀態
    ['#titleInput','#ratingInput','#contentInput'].forEach(sel => $(sel).attr('aria-invalid','false'));
    $('.error').prop('hidden', true);
  });

  // 刪除單筆(Day 11 先從記憶體移除)
  $(document).on('click', '.review-item .btn-del', function(){
    const id = $(this).closest('.review-item').data('id');
    const idx = REVIEWS.findIndex(r => r.id === id);
    if (idx >= 0){ REVIEWS.splice(idx, 1); render(); }
  });

  // 初始化
  $(function(){
    buildDatalist();
    render();
  });
})();

上一篇
Day 10:收藏按鈕互動+收藏清單頁
系列文
給愛追劇的你:30天互動網站挑戰11
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言